卷只是一个概念,和Docker中的卷是一个意思,可以理解为一个存储挂载点,将pod内部的文件系统与pod外部的文件系统挂载起来,以便pod数据的持久化。
与Docker不同的是,Kubernetes支持非常多种类型的卷(超过二十种)
卷是pod容器的组成部分,并非K8S中的顶级资源,其生命周期和pod一致。可以在pod的文件系统的任何位置挂在卷。如下两张图展示了同一个pod中存在多个容器时,在有卷和没有卷时的区别,可以看到在没有卷时,由于三个容器的文件系统分离,因此都各自操作自己的目录,即使他们在功能上是重复的;有卷时,将同一个卷挂在到两个容器的文件系统中,让他们共享这一块存储,既节省空间,也省去了从一个容器向另一个容器中复制的步骤。
卷消失后,卷的文件可能会保持原样,并且挂载到新的卷。这取决于卷的类型。
卷的类型
分为通用卷(k8s系统都会有的)和特殊卷(特殊用途或特殊k8s环境提供的卷)。这里仅介绍最常用的卷。
emptyDir - 创建空目录
最简单的卷类型,它就是一个空目录,容器可以向其中写入任何数据。与pod声明周期一致,pod消失时,卷的内容会丢失。
当pod创建时,就创建一个新的空目录,没有别的额外操作,当pod消失时,该目录随pod一起被删除,这个比较好理解,熟悉容器的应该知道,pod内新建的文件系统与宿主机是隔离的,专属于pod本身,pod删除时,它自然被删除。
尽管它是最简单的卷,但是其它类型的卷都是在它的基础上发展而来的。比如gitRepo卷,是创建一个空目录,再拉取git仓库的文件;其它类型的卷,是创建一个空目录,然后再将指定的目录挂载到该空目录下。
创建emptyDir卷时需要在pod配置文件中指定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| apiVersion: v1 kind: Pod metadata: name: fortune spec: containers: - image: luksa/fortune name: html-generator volumeMounts: - name: html mountPath: /var/htdocs - image: nginx:apline name: web-server volumnMounts: - name: html mountPath: /usr/share/nginx/html readOnly: true ports: - containerPort: 80 protocol: TCP volumes: - name: html emptyDir: {}
|
gitRepo - 挂载git仓库文件(弃用)
来源于emtryDir卷,它是在pod启动时检出git仓库中特定版本的内容填充到目录中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| apiVersion: v1 kind: Pod metadata: name: gitrepo-volume-pod spec: containers: - image: nginx:apline name: web-server volumnMounts: - name: html mountPath: /usr/share/nginx/html readOnly: true ports: - containerPort: 80 protocol: TCP volumes: - name: html gitRepo: repository: http://git.dev.moumoux.com/ergedd/ergeddUtils revisioin: master directory: .
|
由于gitRepo卷是在卷创建时从git克隆一次代码,因此当git仓库更新时pod是无法同步的。要做到同步,有如下两种方式
- 删除pod再创建
- 新建一个sidecar容器,用于同步卷中内容。Docker hub中搜索“git syc”可以看到很多相关的实现。
hostPath - 挂载工作节点上的文件
hostPath卷指向pod所属节点上的特定文件路径,同一个节点运行的两个pod通过hostPath卷可以看到相同的内容。它是一种持久存储的卷,因为节点文件系统中的内容并不会随着pod的释放而被删除。
它的缺点是对于pod来说不稳定,因为当发生pod调度时,可能被调度到另一个节点,这样对于这个pod来说之前写在节点上的文件就不见了。
使用场景
- 只读访问节点上的文件,比如某些pod专门用于访问节点上的日志
- 有状态应用的pod,需要将数据存入节点,且pod分配稳定。比如Kubernetes的存储组件——etcd
网络存储卷
总结一下上面说的三种卷
- Empty:在pod内部创建一个空目录,与pod外部无关
- gitRepo:在pod内部创建一个空目录,然后从git仓库拉取文件到该目录,与pod外部无关
- hostPath:在pod内部创建一个空目录,然后将其所在工作节点的文件挂载到该目录,与工作节点有关
还有一个场景未覆盖到:将pod的数据存储在一个与工作节点无关的独立的位置——网络存储(NAS),比如运行一个有状态应用mongodb,然后挂载GCE磁盘:
下面展示了几种合适的卷
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| ...... spec: volumes: - name: mondodb-data gcpPersistentDisk: pdName: mondoDb fsType: ext4 containers: - image: nginx:apline name: web-server volumnMounts: - name: mondodb-data mountPath: /usr/db ......
...... spec: volumes: - name: mondodb-data awsElasticBlockStore: volumnId: my-volume fsType: ext4 ......
...... spec: volumes: - name: mondodb-data nfs: server: 1.2.3.4 path: /some/path ......
|
当然,除了云服务厂商提供的磁盘,还可以挂载nfs卷,访问自建的网络磁盘
其它
卷有很多种类型,远不止上述那些,详情参考官方手册。在查看时,尤其注意几个卷类型
- local:挂载本地磁盘、分区或目录
- configmap:挂载ConfigMap
- secret:挂载Secret
持久卷
上面创建的持久化的卷,需要开发人员在配置pod时配置NAS的网络存储位置,使得开发人员、pod与底层的实际存储技术和存储地址耦合了起来。不符合k8s的策略,即对开发人员将底层技术抽象、解耦。
于是引入了持久卷和持久卷声明的概念,即PV和PVC
持久卷(PersistentVolume):封装了底层的存储技术,不属于任何命名空间,整个k8s共享,属于基础资源。是k8s集群管理员需要创建的资源。
持久卷声明(PersistentVolumeClaim):用于开发人员声明需要的存储容量和访问模式,在命名空间内部。是开发人员自己需要创建的资源。
持久卷,很好理解:卷是将pod外部的存储挂载到pod的挂载方式的声明,但每次都要在创建pod时声明,持久卷,就是对这种挂在方式声明的持久化,方便直接使用。
二者都是k8s的顶级资源,需要单独创建,且不属于任何命名空间。
注意:引入PV和PVC整体而言是提升了复杂度,但如果一个团队有系统管理员负责维护PV和PVC,开发人员的工作会减少。
一个常规的创建PV和PVC的流程如下
与不使用持久卷相比,好处显而易见
创建持久卷
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| apiVersion: v1 kind: PersistentVolume metadata: name: mogodb-pv spec: capacity: storage: 1Gi accessModes: - ReadWriteOnce - ReadOnlyMany persistentVolumeReclaimPolicy: Retain gcePersistentDisk: pdName: mongodb fsType
|
创建持久卷声明
1 2 3 4 5 6 7 8 9 10 11
| apiVersion: v1 kind: PersistentVolumeClaim metadata: name: mongodb-pvc spec: resources: requests: storage: 1Gi accessModes: - ReadWriteOnce storageClassName: ""
|
该声明和上面的持久卷是相匹配的,因此使用该声明时上述持久卷会被分配。
使用持久卷
1 2 3 4 5 6 7
| ...... volumes: - name: mongodb-data persistentVolumeClaim: claimValue: mongodb-pvc ......
|
使用后的结构
持久卷的回收机制
- Retain: 持久卷声明被释放后依然保留该持久卷,当PVC释放它后,它保持为释放状态,无法再被新的PVC引用。并且以后可以再配置给其它pod使用(文章是这么说,但我的问题是既然无法再被新的PVC引用,新的pod又如何去用这个被释放了的持久卷呢?)。删除该持久卷的唯一方式是手动删除持久卷资源。
- Recycle: 持久卷声明被释放后,该持久卷可以被新的PVC引用(数据应该还存在)
- Delete: 释放后删除持久卷
StorageClass
上面我们看到了持久卷的用法,但它还有一个问题是需要集群管理员维护存储卷资源,这样不够科学。于是有了StorageClass这个资源。它是一个顶级资源,并且和持久卷一样,也是不属于任何命名空间的。
StorageClass定义了一个策略,在开发者使用PVC时创建一个新的持久卷,好处是不用管理员维护,并且与预先设置的持久卷不同,他不会耗尽持久卷存储。
谁提供存储
听起来很好,只声明存储类,不声明PV,那么最终的存储设备是谁提供呢?答案是集群供应商,这是他们提供的插件。比如下面的GCE,在minikube这样的本地集群中也有相应的插件来管理。
- 对于云服务商,一般会根据PVC的需求创建一个云盘
- 对于minikube
使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast provisioner: kubernetes.io/gce-pd parameters: type: pd-ssd zone: europe-west1-b
apiVersion: v1 kind: PersistentVolumeClaim metadata: name: mongodb-pvc spec: storageClassName: fast resources: requests: storage: 100Mi accessModes: - ReadWriteOnce
|
创建没有StorageClass的PVC
创建PVC时不指定SC,会被分配一个默认的SC。通过kubectl get sc
可以查看默认的存储类。
强制将预先设置的PV绑定到PVC
1 2 3 4 5
| ...... kind: PersistentVolumeClaim spec: storageClassName: "" ......
|
最佳实践 - minikube部署Redis集群
首先我们需要了解:minikube是运行在隔离环境中的,比如VM、容器。在我的macbookpro m1上,它运行在docker中,因此要先将本地文件挂载minikube中,然后才能挂载到pod。
挂载到minikube
1 2
| mkdir ~/.minikube/data:/data minikube mount ~/.minikube/data:/data
|
创建PV
我们的集群有三个节点,因此需要三个数据卷,因此我们创建三个PV,分别对应三个不同的文件夹,他们每个都只能被使用一次。
注意这里我们没有创建PVC,因为集群是有状态应用,其PVC实配置在volumeClaimTemplates中的,所以后面再配。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| apiVersion: v1 kind: PersistentVolume metadata: name: redis-pv1 spec: capacity: storage: 10Mi volumeMode: Filesystem accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain local: path: /data/redis-data-1 storageClassName: "" nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: - minikube --- apiVersion: v1 kind: PersistentVolume metadata: name: redis-pv2 spec: capacity: storage: 10Mi volumeMode: Filesystem accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain local: path: /data/redis-data-2 storageClassName: "" nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: - minikube --- apiVersion: v1 kind: PersistentVolume metadata: name: redis-pv3 spec: capacity: storage: 10Mi volumeMode: Filesystem accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain local: path: /data/redis-data-3 storageClassName: "" nodeAffinity: required: nodeSelectorTerms: - matchExpressions: - key: kubernetes.io/hostname operator: In values: - minikube
|
创建Redis集群配置文件
三个节点,每个节点的配置文件都长得一样
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| apiVersion: v1 kind: ConfigMap metadata: name: redis-cluster data: update.sh: | #!/bin/sh REDIS_NODES="/data/nodes.conf" sed -i -e "/myself/ s/[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}/${POD_IP}/" ${REDIS_NODES} exec "$@" redis.conf: |+ bind 0.0.0.0 cluster-enabled yes cluster-require-full-coverage no cluster-node-timeout 30000 cluster-config-file /data/nodes.conf cluster-migration-barrier 1 appendonly yes protected-mode no
|
创建网络
1 2 3 4 5 6 7 8 9 10 11 12 13
| apiVersion: v1 kind: Service metadata: name: redis-cluster labels: app: redis spec: ports: - name: redis-port port: 6379 clusterIP: None selector: app: redis-cluster
|
创建集群
这是一个StatefulSet,注意volumeClaimTemplates,它指定的存储大小和上面的PV要匹配。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| apiVersion: apps/v1 kind: StatefulSet metadata: name: redis-cluster spec: serviceName: redis-cluster replicas: 3 selector: matchLabels: app: redis-cluster template: metadata: labels: app: redis-cluster spec: containers: - name: redis image: redis:6.2.1-alpine ports: - containerPort: 6379 name: client - containerPort: 16379 name: gossip command: ["/conf/update.sh", "redis-server", "/conf/redis.conf"] env: - name: POD_IP valueFrom: fieldRef: fieldPath: status.podIP volumeMounts: - name: conf mountPath: /conf readOnly: false - name: data mountPath: /data readOnly: false volumes: - name: conf configMap: name: redis-cluster defaultMode: 0755 volumeClaimTemplates: - metadata: name: data spec: accessModes: [ "ReadWriteOnce" ] resources: requests: storage: 10Mi storageClassName: ""
|
跑起来
可以发现它们确实跑起来了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| zouguodong@zouguodongdeMBP redis-cluster % kubectl get statefulset NAME READY AGE redis-cluster 3/3 9m48s
zouguodong@zouguodongdeMBP redis-cluster % kubectl get pods NAME READY STATUS RESTARTS AGE redis-cluster-0 1/1 Running 0 9m53s redis-cluster-1 1/1 Running 0 9m34s redis-cluster-2 1/1 Running 0 9m31s
zouguodong@zouguodongdeMBP redis-cluster % kubectl get pvc NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE data-redis-cluster-0 Bound redis-pv1 10Mi RWO 10m data-redis-cluster-1 Bound redis-pv2 10Mi RWO 9m42s data-redis-cluster-2 Bound redis-pv3 10Mi RWO 9m39s
zouguodong@zouguodongdeMBP redis-cluster % kubectl get pv NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE redis-pv1 10Mi RWO Retain Bound default/data-redis-cluster-0 12m redis-pv2 10Mi RWO Retain Bound default/data-redis-cluster-1 12m redis-pv3 10Mi RWO Retain Bound default/data-redis-cluster-2 12m
|
可以查看一个PVC的yaml输出,可以发现它使用了volumeName强绑定了一个pv。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| zouguodong@zouguodongdeMBP redis-cluster % kubectl get pvc data-redis-cluster-0 -o yaml apiVersion: v1 kind: PersistentVolumeClaim metadata: annotations: pv.kubernetes.io/bind-completed: "yes" pv.kubernetes.io/bound-by-controller: "yes" creationTimestamp: "2021-12-09T06:42:12Z" finalizers: - kubernetes.io/pvc-protection labels: app: redis-cluster name: data-redis-cluster-0 namespace: default resourceVersion: "20518" uid: ceedaaf5-704b-4cfa-a91b-66fb2e6793b1 spec: accessModes: - ReadWriteOnce resources: requests: storage: 10Mi storageClassName: "" volumeMode: Filesystem # 这里强绑定了,也是StatefulSet的特点 volumeName: redis-pv1 status: accessModes: - ReadWriteOnce capacity: storage: 10Mi phase: Bound
|
后续
到这里,集群建立还没完成,因为现在每个集群都是主节点,还没构成一个集群。如果要构成一个完整的集群,需要执行更多操作,具体可参考这篇文章
总结
重点是理解几个概念
- 卷Volume
- PV
- PVC
- StorageClass
此外,本文未涉及的一些概念同样重要,读者可以通过官方手册学习
- CSI:Kubernetes定义的容器存储标准接口,就像CRI(容器运行标准接口)
- projected卷:即将多个卷来源合并成一个卷,目前支持secret、downwardAPI、configMap、serviceAccountToken
- 卷克隆:从已有的卷克隆
- 临时卷:即不需要保存的卷
- 卷快照:见名知意
参考
- 首先教你入门——《Kubernetes in Action》
- 然后必须看——官方手册